一、什么是DoS?
DoS是Denial of Service的简称,即拒绝服务,造成DoS的攻击行为被称为DoS攻击,其目的是使计算机或网络无法提供正常的服务。拒绝服务存在于各种网络服务上,这个网络服务可以是c、c++实现的,也可以是go、java、php、python等等语言实现。
二、Java DoS的现状
在各种开源和闭源的java系统产品中,我们经常能看到有关DoS的缺陷公告,其中大部分都是耗尽CPU类型或者业务卸载类型的DoS。耗尽CPU类型的DoS大体上主要包括“正则回溯耗尽CPU、代码大量重复执行耗尽CPU、死循环代码耗尽CPU”等。而业务卸载DoS这一类型的DoS则和系统业务强耦合,一般情况下并不具备通用性。下面用几个简单例子对其进行简单描述。
正则回溯耗尽CPU
1
Pattern.matches(controllableVariableRegex, controllableVariableText)
代码大量重复执行耗尽CPU
1
2
3for(int i = 0; i < controllableVariable; i++) {
//do something,e.g. consume cpu
}死循环耗尽CPU
1
2
3while(controllableBoolVariable) {
//do something,e.g. consume cpu
}业务卸载DoS(当任意用户可访问到uninstall方法的情况下,业务就可能会被恶意卸载,导致正常用户的服务被拒绝)
1
2
3
4
5
6
7
8
9
10
11
12
13private final Set<String> availables = new HashSet();
public void service(String type, String name) {
if (availables.contains(type)) {
//do service
} else {
//reject service
}
}
public void uninstall(String type) {
availables.remove(type)
}
我曾经在挖掘XStream反序列化利用链的时候,也找到过可以发起“正则回溯耗尽CPU、死循环耗尽CPU”类型DoS攻击的利用链,并且最终获得了XStream的多个CVE编号标记和署名。
- java.io.ByteArrayInputStream(死循环耗尽CPU)
https://x-stream.github.io/CVE-2021-21341.html - java.util.Scanner(正则回溯耗尽CPU)
https://x-stream.github.io/CVE-2021-21348.html
难道Java系统中,只存在这些类型的DoS吗?我不认为是这样的,我认为Java系统中,必然存在着大量其他类型的DoS缺陷,只是我们还没发现。当我在某一天审计一个Java系统时,灵光一闪,突然发现了一个和这些DoS类型都不一样的缺陷,并且,在通过对其他大量的Java系统审计时,它普遍存在,我知道了这是一个具有普遍性存在的缺陷 - Memory DoS
三、Java异常机制
在c、c++等语言实现的网络服务中,可能存在空指针DoS、CPU耗尽DoS等等各种各样类型的DoS,为什么在Java中,DoS的类型却少得可怜?这又不得不说起Java中的异常机制了。
Java异常在JRE源码实现中,主要分为了java.lang.Exception和java.lang.Error,它们都有一个共同的实现java.lang.Throwable。经常写Java代码的程序员,可能最不喜欢就是遇到这样的麻烦了。
(关于异常的描述,简单参考了一下runoob)
异常是程序中的一些错误,但并不是所有的错误都是异常,并且错误有时候是可以避免的。
比如说,你的代码少了一个分号,那么运行出来结果是提示是错误 java.lang.Error;如果你用System.out.println(11/0),那么你是因为你用0做了除数,会抛出 java.lang.ArithmeticException 的异常。
异常发生的原因有很多,通常包含以下几大类:
- 用户输入了非法数据。
- 要打开的文件不存在。
- 网络通信时连接中断,或者JVM内存溢出。
这些异常有的是因为用户错误引起,有的是程序错误引起的,还有其它一些是因为物理错误引起的。-
要理解Java异常处理是如何工作的,你需要掌握以下三种类型的异常:
- 检查性异常:最具代表的检查性异常是用户错误或问题引起的异常,这是程序员无法预见的。例如要打开一个不存在文件时,一个异常就发生了,这些异常在编译时不能被简单地忽略。
- 运行时异常: 运行时异常是可能被程序员避免的异常。与检查性异常相反,运行时异常可以在编译时被忽略。
- 错误: 错误不是异常,而是脱离程序员控制的问题。错误在代码中通常被忽略。例如,当栈溢出时,一个错误就发生了,它们在编译也检查不到的。
关于Java异常机制的描述,上述已经说得很清楚了,当出现异常的时候,往往大部分是可以被捕获处理的,但是,当出现错误的时候,意味着程序已经不能正常运行了。也就是说,我们在Java系统中产生的大部分异常,是没办法导致DoS的,只有造成了错误,才会使程序不能正常运行,导致DoS,这就是为什么在Java中,DoS的类型相对少的原因了。
翻看JRE中关于错误java.lang.Error的实现,可以看到非常非常之多,而今天的主角是java.lang.OutOfMemoryError,也就是说,我们如果能让程序产生java.lang.OutOfMemoryError错误,就可以实现DoS。大多数java程序员应该都很熟悉它,抛出java.lang.OutOfMemoryError错误,一般都出现在jvm内存不足,或者内存泄露导致gc无法回收中。
四、一种普遍存在的Memory DoS
上一节说到了,我们如果能让程序产生java.lang.OutOfMemoryError错误,就可以实现DoS,它叫Memory DoS,一种耗尽内存,导致程序抛出错误的DoS攻击。
那么,如何让一个Java系统产生java.lang.OutOfMemoryError错误呢?答案必然是“耗尽内存”!
我曾经通过简单的代码扫描工具,对多个java系统、组件进行了扫描,其中发现了大量可利用的Memory DoS缺陷,是的,这意味着我能让这些系统产生java.lang.OutOfMemoryError错误。而这些系统、组件中包含了Java SE、WebLogic、Spring、Sentinel、Jackson、xstream等等比较著名的系统和组件。
0x01 Java SE
我在对Java SE的扫描中,发现了有三个class在反序列化的时候,可以导致系统产生java.lang.OutOfMemoryError错误,对系统进行Memory DoS攻击。我马上报告给了Oracle,最终在Java SE 8u301中得到修复,并且我在2021.07的安全通知中https://www.oracle.com/security-alerts/cpujul2021.html,得到了”Security-In-Depth”的署名
让我们先看看Java SE 8u301的修复和修复前,它们之间的差异对比吧。
java.time.zone.ZoneRules#readExternal
修复前:1
2
3
4
5
6
7
8
9static ZoneRules readExternal(DataInput in) throws IOException, ClassNotFoundException {
int stdSize = in.readInt();
long[] stdTrans = (stdSize == 0) ? EMPTY_LONG_ARRAY
: new long[stdSize];
for (int i = 0; i < stdSize; i++) {
stdTrans[i] = Ser.readEpochSec(in);
}
...
}
修复后:
1 | static ZoneRules readExternal(DataInput in) throws IOException, ClassNotFoundException { |
java.awt.datatransfer.MimeType#readExternal
修复前:1
2
3
4
5
6
7
8
9
10
11
12
13
14public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException {
String s = in.readUTF();
if (s == null || s.length() == 0) { // long mime type
byte[] ba = new byte[in.readInt()];
in.readFully(ba);
s = new String(ba);
}
try {
parse(s);
} catch(MimeTypeParseException e) {
throw new IOException(e.toString());
}
}
修复后:
1 | public void readExternal(ObjectInput in) throws IOException, |
com.sun.deploy.security.CredentialInfo#readExternal
修复前:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException {
try {
this.userName = (String)var1.readObject();
this.sessionId = var1.readLong();
this.domain = (String)var1.readObject();
this.encryptedPassword = new byte[var1.readInt()];
for(int var2 = 0; var2 < this.encryptedPassword.length; ++var2) {
this.encryptedPassword[var2] = var1.readByte();
}
} catch (Exception var3) {
Trace.securityPrintException(var3);
}
}
修复后:
1 | public void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException { |
通过这三个例子,大家看出来了什么了吗?
是的,这是一种利用数组在初始化时,容量参数可控,从而存在的一种Memory DoS缺陷。当恶意用户控制了容量参数,把参数值大小设置为int最大值2147483647-2(2147483645是数组初始化最大限制),那么,在数组初始化时,JVM会因为内存不足,从而导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。
0x02 WebLogic
我在对Weblogic的扫描中,发现了有几十个class在反序列化的时候,可以导致系统产生java.lang.OutOfMemoryError错误,对系统进行Memory DoS攻击。扫描虽然使用了几分钟,但我写报告却花了大量的时间:)。
在报告给了Oracle后,2021.07的安全通知中https://www.oracle.com/security-alerts/cpujul2021.html,我得知其被修复,并且得到了CVE-2021-2344, CVE-2021-2371, CVE-2021-2376, CVE-2021-2378四个CVE以及署名。
WebLogic中的Memory DoS和Java SE的没有太大的差别,就不一一列出来了。
com.tangosol.run.xml.SimpleDocument#readExternal(java.io.ObjectInput)
1 | public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { |
不过,前面WebLogic以及Java SE中,举的例子都是在数组初始化时进行的攻击的sink。实际上,还有另外一种,它就是java集合Collection,当一个Collection在初始化时,往往在其内部实现中,会初始化一个或多个数组,存储数据。那么,如果在Collection初始化时,我们可以控制它的容量参数,就能让JVM内存不足,从而导致系统产生java.lang.OutOfMemoryError错误,造成Memory DoS。
weblogic.deployment.jms.PooledConnectionFactory#readExternal
1 | public void readExternal(ObjectInput in) throws IOException { |
可以看到,代码中的这一行HashMap初始化,我们是可以控制它的构造参数值大小的。1
this.poolProps = new HashMap(numProps);
现在HashMap的构造参数我们可控了,意味着,我们可以自由指定其初始化容量的大小,若其大小超过了JVM可用内存,将会导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。
0x03 Spring
在Spring中,我对其mvc框架的源码进行了简单的审计,发现在一个HttpMessageConverter中,存在数组初始化容量参数可控的情况,当对http进行简单构造后,将能导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。
我在报告给Spring官方后,他们认为这虽然是一个安全问题,但是理应由其他系统去对其进行限制,所以,不予修复。
我也向Spring开发者提供了修复的建议,但他们最终没有采纳,所以,这个安全问题依然存在,不过幸运的是,大家无需担心,因为这个漏洞的利用,需要一定的前提条件。
org.springframework.http.converter.ByteArrayHttpMessageConverter
1 | @Override |
可以看到,当我们发起的http请求中,我们是可以利用Content-Length这个http header,控制byte数组的初始化容量,如果我们传入的Content-Length的大小为Long类型的最大值,将会导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS攻击。
不过想要利用这个漏洞,前提是需要开发者编写了某种实现的Controller,它需要Controller接收一个byte[]类型的参数,因为只有这样,Spring在http payload转换时,才是使用org.springframework.http.converter.ByteArrayHttpMessageConverter对其进行处理。1
2
3
4
5
6
7
8
9
10
11/**
* @author threedr3am
*/
@RestController
public class TestController {
@PostMapping(value = "/test")
public String test(@RequestBody byte[] bytes) {
return "ok";
}
}
0x04 Sentinel
github:https://github.com/alibaba/Sentinel
前面说了Java SE、WebLogic、Spring,但是所有无外乎都是针对数组初始化参数的攻击,那么,还有没有其他能造成系统产生java.lang.OutOfMemoryError错误的Memory DoS呢?
答案是“有”的,我在对Alibaba Sentinel进行审计的时候,发现了它的管控平台,也可以称之为注册中心(sentinel-dashboard),存在一个无需认证即可访问的http endpoint,稍加利用,就能导致系统产生java.lang.OutOfMemoryError错误。如果熟悉Sentinel的人都清楚,它是一个开源的限流熔断组件,在官方实现中,接入Sentinel的客户端,都会向注册中心进行服务注册,可能是为了降低使用、接入Sentinel的难度,这个服务注册的http endpoint是无需认证即可访问的。
这个http endpoint是/registry/machine
com.alibaba.csp.sentinel.dashboard.controller.MachineRegistryController#receiveHeartBeat
1 | @ResponseBody |
-> com.alibaba.csp.sentinel.dashboard.discovery.AppManagement#addMachine
1 | @Override |
-> com.alibaba.csp.sentinel.dashboard.discovery.SimpleMachineDiscovery#addMachine
1 | private final ConcurrentMap<String, AppInfo> apps = new ConcurrentHashMap<>(); |
通过跟踪上述代码,可以看到,用户提交的数据,径直得往内存中存储。因为这个http endpoint支持GET、POST请求,所以当我们在发送http请求中使用POST方式,并且在body中添加非常大的数据,比如app参数,放一个1MBytes或者10MBytes,亦或者更大的数据,那么,将会导致服务端内存耗尽,而产生java.lang.OutOfMemoryError错误,实现Memory DoS。
0x05 注意之处
有的读者在测试数组初始化Memory DoS的时候,发现虽然可以使系统产生java.lang.OutOfMemoryError错误,但是系统并没有因此而崩溃,实现完整的Memory DoS,这是什么原因呢?
且看下面这三个例子:
完整Memory DoS
1
2
3
4
5
6private byte[] bytes;
public ? service(int size) {
bytes = new byte[size];
//do something
}一定时间内的Memory DoS
1
2
3
4public ? service(int size) {
byte[] bytes = new byte[size];
//do 5s something
}短暂的Memory DoS
1
2
3
4public ? service(int size) {
byte[] bytes = new byte[size];
//do 100ms something
}
在看完这三个例子之后,我相信大部分熟悉JVM gc机制的人都能立马懂了,其实这就是共享变量引用对象和局部变量引用对象之间的区别。
因为JVM的gc机制是根据对象引用来确定对象内存是否需要被回收的,而这里,我们初始化的数组对象如果是局部变量引用,并且仅有局部变量引用,那么,这就意味着,当这个线程栈执行完成之后,如果引用已经不存在了,那么JVM在执行gc的时候就会回收这一块内存,所以,单独这样我们只能得到短暂时间内的Memory DoS,可能是5秒(已经比较优质了),也可能只有0.1秒,没办法完全实现Memory DoS,而只有让这个对象生命周期足够长,或者引用一直存在,那么,在其生命周期内,我们才能长时间占用JVM堆足够多的内存,让JVM无法回收这部分内存,从而实现Memory DoS。
还有一个最重要的点,JVM对应数组初始化的大小是有限制的,最大数组长度是2147483645,约等于2047.999997138977051MBytes,所以,如果遇到可控共享变量引用对象的场景,我们只能控制一个对象数组大小,一旦JVM最大可用堆内存远比其数组大的话,对于实现Memory DoS也是比较难的。
五、总结
- java中只要能产生Error,大概率就能造成DoS
- 通过控制数组Array、集合Collection初始化容量参数,可以实现Memory DoS
- 往集合Collection中插入大量的垃圾数据,也可以实现Memory DoS
由于这篇文章实际上没什么硬核的东西,所以,就不写太多的例子了,以免又臭又长。
六、一些关于Memory DoS的CVE
- CVE-2021-2344, CVE-2021-2371, CVE-2021-2376, CVE-2021-2378: WebLogic Deserialization Memory DoS
- CVE-2021-22095: Spring-AMQP Remote Denial of Service - Out of Memory Error with a Large Message Body